RBAC 메뉴 트리 설계 — soft-delete와 upsert-revive

NOTE

메뉴 트리 + 권한별 접근 매핑을 다루는 RBAC 화면에서 반복적으로 나타나는 설계 문제(테이블 소유권 혼재, 부모-자식 정합성, 삭제-재등록 시 이력 보존)와 그 해결 패턴.


1. 테이블 소유권을 화면 하나로 단일화한다

메뉴 마스터(이름/URL/레벨/순서 등)와 권한별 접근 매핑(이 권한이 이 메뉴를 쓸 수 있는지)을 서로 다른 두 화면이 동시에 수정하게 두면, 한쪽 화면의 변경이 다른 화면의 전제를 깨뜨리는 결함이 생긴다.

AS-IS 결함 사례:
1. 메뉴관리 화면에서 메뉴를 등록하며 권한 A에 매핑까지 함께 생성
2. 권한관리 화면에서 권한 A에 대해 그 메뉴를 "미사용"으로 변경 → 매핑 소프트삭제
3. 메뉴는 어떤 권한에도 매핑되지 않은 상태가 됨(고아)
4. 메뉴관리 화면 그리드엔 여전히 보이지만, 매핑이 없어 수정/삭제 버튼이 조건부로 숨겨짐(고립)

해결 원칙: “권한별 접근 매핑” 테이블을 쓰는 화면을 하나로 단일화한다. 마스터 화면은 마스터 테이블만 건드리고, 매핑은 매핑 전용 화면에서만 건드린다.

화면담당 테이블하지 않는 것
마스터 화면(메뉴 등록/수정)메뉴 마스터만권한 매핑 테이블은 건드리지 않음
매핑 화면(권한별 메뉴 부여)매핑 테이블만마스터 정보는 건드리지 않음

신규 메뉴를 만들 때도 “① 마스터에서 메뉴 생성 → ② 매핑 화면에서 권한별 사용여부를 켠다”는 2단계 흐름이 표준(RBAC 정석)이며, 등록 시점에 1개 권한으로 자동 매핑하는 1단계 흐름은 위 결함의 근본 원인이므로 지양한다.

게이트 값과 실제 권한 비트를 분리한다

메뉴 단위로 “이 메뉴에서 조정 가능한 권한 종류”(게이트, 마스터 소유)와, 권한별로 “실제로 부여된 권한 비트”(매핑 소유)를 별도 필드로 나눈다. 게이트가 잠근 비트는 매핑 화면에서 애초에 조정 불가하게 만들어, 마스터의 정책과 매핑의 실제 값이 항상 일관되도록 강제한다.

식별자 변경은 매핑이 존재하면 잠근다

메뉴 ID나 상위 계층 값 등 식별자를 바꾸면 기존 매핑이 참조를 잃고 고아가 된다. 매핑이 1건이라도 존재하면 식별자 변경을 차단하고, 변경하려면 먼저 매핑을 정리하도록 유도한다.


2. 부모-자식 캐스케이드 — 트리 렌더러가 강제하는 데이터 규칙

메뉴 트리 UI가 “매핑 테이블 JOIN 마스터 테이블” 결과로 골격을 그리는 구조라면, 자식 메뉴가 보이려면 그 조상(부모/조부모) 메뉴도 매핑에서 사용중으로 등록돼 있어야 한다.

자식 Y + 부모 N  → 진입 경로가 없는 고아 메뉴(트리에 안 보임)
부모 N (자손 Y가 남아 있어도) → 트리 진입점 자체가 소실

이건 UX 취향이 아니라 렌더러가 강제하는 데이터 정합성 규칙이다. 해결은 하이브리드로 한다.

레이어역할
UI화면에 보이는 조상/자손을 즉시 dirty 처리(빠른 피드백)
백엔드(저장 처리)필터로 화면에 안 보이는 조상·자손까지 최종 보정(정합성 보장)

백엔드 저장 로직의 캐스케이드 규칙(레벨 판정 기준으로):

사용여부(Y/N)레벨동작
Y최하위자신 upsert + 상위 조상들의 매핑 존재를 보장(없으면 생성)
Y중간자신 upsert + 최상위 조상 보장
Y최상위자신 upsert(조상 없음)
N최하위자신만 소프트삭제(자손 없음)
N중간/최상위자신 + 하위 전체(prefix 패턴) 동반 소프트삭제

조상을 보장할 때는 이미 있으면 기존 권한 비트를 보존하고, 없으면 기본 권한으로 새로 만든다 — 이렇게 해야 부모를 “복구”하는 과정에서 부모의 기존 세부 권한 설정이 날아가지 않는다.


3. Soft delete + Upsert 되살리기 — 감사 이력을 지키면서 재등록을 허용한다

RBAC 테이블은 삭제 이력 추적이 중요하므로 물리 삭제 대신 사용여부 플래그로 논리 삭제하고, 같은 식별자로 다시 등록할 때는 INSERT ... ON DUPLICATE KEY UPDATE 사용여부='1'로 기존 행을 되살린다.

1. 권한 X 삭제        → 권한코드(사용여부=0) + 매핑(사용여부=0) 소프트삭제(행은 남음)
2. 같은 ID로 재생성     → "활성 중복" 체크는 통과(활성 행이 없으므로) → INSERT
                       → ON DUPLICATE KEY UPDATE로 기존 행이 되살아남(중복 행 생성 안 됨)
3. 되살아난 메뉴 매핑    → upsert 시 화면이 보낸 최신 권한 비트로 덮어써짐(오래된 값 잔류 없음)

안전성의 핵심은 두 가지 체크의 역할 분리다:

  • “활성 중복” 체크(삭제된 것은 통과, 활성 행만 차단)가 INSERT 시점에 진짜 중복만 막는다.
  • 되살릴 때 매핑은 항상 화면이 보낸 최신 값으로 덮어쓰기하므로, 예전에 삭제되기 전 남아있던 값이 몰래 부활하지 않는다.
  • 조회 쿼리는 항상 “사용중” 행만 보므로, 사용여부가 켜지기 전까지는 화면에 노출되지 않는다.

일반화: 이력 보존이 필요한 마스터/매핑 테이블 전반에 “soft delete + upsert 되살리기 + INSERT 시점 활성중복 차단”을 표준 패턴으로 문서화해두면, 팀원마다 다른 방식(물리삭제, 별도 이력테이블 등)으로 구현하는 것을 막을 수 있다.

시스템(테넌트) 변경 시 매핑 고아 방지

권한 코드가 여러 시스템에 걸쳐 있고 PK가 (시스템, ID) 복합키라면, 시스템 값 변경은 그 권한의 기존 매핑을 전부 고아로 만든다. 두 겹으로 방어한다.

  1. 대상 시스템에 같은 ID가 이미 활성 상태로 있으면 차단(충돌 방지)
  2. 변경 전 (시스템, ID)에 매핑이 하나라도 있으면 차단(고아 방지) — UI에서도 매핑이 있으면 시스템 선택을 비활성화해 이중 방어

파괴적으로 매핑까지 함께 이관(cascade)하는 대신, 차단하고 신규 생성으로 유도하는 쪽이 더 안전한 기본값이다.


4. 신규 CRUD 엔드포인트를 만들 때 흔한 실수

  • HTTP 메서드 표준을 안 지킴 — 프레임워크가 PUT을 지원하지 않는데 프론트가 PUT을 쏘는 등, REST 메서드 매핑을 프레임워크 라우팅 규칙과 다시 확인한다.
  • Bean 이름 prefix 실수 — “조회/생성/수정/삭제” 각각을 라우팅하는 prefix 컨벤션(read|, create|, update|, delete|)이 있는 프레임워크에서, 삭제 Bean에 handle|을 붙이는 식의 실수로 라우팅이 안 맞아 “빈을 못 찾음” 오류가 난다.
  • 쓰기 트랜잭션 래핑 누락 — 새 Handle에 트랜잭션 래핑과 inData→procData 복사를 빠뜨리면, 검증 단계에서 데이터 타입이 바뀌어 정작 INSERT 시점엔 빈 데이터가 들어간다.
  • 그리드 리렌더 미감지 — 체크박스 셀의 field가 실제 데이터가 아니라 계산된 가짜 값이면, 그리드 라이브러리가 셀 변화를 감지 못해 조회해도 갱신되지 않는다. 조회 시점마다 그리드 자체를 강제로 리마운트(예: React의 key prop 증가)하는 우회책이 필요할 수 있다.
  • 원본 스냅샷 없는 dirty 해제 — 되돌리기(취소) 후 “변경됨” 표시가 안 풀리는 문제는, 조회 시점의 원본 값을 스냅샷으로 저장해두고 매 변경마다 그 스냅샷과 비교해서 dirty 여부를 재계산해야 해결된다.

5. 요약 체크리스트 — RBAC/트리형 마스터-매핑 화면을 설계/리뷰할 때

[ ] 매핑 테이블을 수정하는 화면이 2개 이상인가? → 하나로 단일화
[ ] 게이트(조정 가능 범위)와 실제 권한 비트가 같은 필드에 섞여 있는가? → 분리
[ ] 트리 렌더러가 매핑 JOIN 마스터로 골격을 그리는가? → 부모-자식 캐스케이드 규칙 필요
[ ] 이력 추적이 필요한 테이블에서 물리 삭제를 쓰고 있는가? → soft delete + upsert 되살리기로 전환 검토
[ ] 재등록(되살리기) 시 매핑 값이 화면의 최신 값으로 덮어써지는가, 예전 값이 남는가?
[ ] 복합키 중 일부(시스템/테넌트 등)를 바꿀 수 있게 열어뒀는가? → 매핑 존재 시 차단 방어 필요

관련 문서